@rse/specbook 1.2.15 → 1.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +98 -11
  2. package/dst/specbook-api.d.ts +22 -21
  3. package/dst/specbook-api.js +80 -27
  4. package/dst/specbook-cli.js +50 -23
  5. package/dst/specbook-cmd-describe.js +8 -4
  6. package/dst/specbook-cmd-export.d.ts +4 -1
  7. package/dst/specbook-cmd-export.js +22 -12
  8. package/dst/specbook-cmd-init.js +2 -8
  9. package/dst/specbook-cmd-lint.d.ts +2 -1
  10. package/dst/specbook-cmd-lint.js +31 -28
  11. package/dst/specbook-config.d.ts +7 -0
  12. package/dst/specbook-config.js +6 -1
  13. package/dst/specbook-coverage.js +3 -4
  14. package/dst/specbook-diagram.d.ts +3 -1
  15. package/dst/specbook-diagram.js +78 -27
  16. package/dst/specbook-export-ast.d.ts +2 -1
  17. package/dst/specbook-export-ast.js +16 -8
  18. package/dst/specbook-export-common.d.ts +7 -0
  19. package/dst/specbook-export-common.js +36 -3
  20. package/dst/specbook-export-html.css +64 -18
  21. package/dst/specbook-export-html.d.ts +2 -1
  22. package/dst/specbook-export-html.js +517 -275
  23. package/dst/specbook-export-image.d.ts +3 -0
  24. package/dst/specbook-export-image.js +121 -0
  25. package/dst/specbook-export-md.d.ts +8 -2
  26. package/dst/specbook-export-md.js +67 -6
  27. package/dst/specbook-export-pdf.js +7 -4
  28. package/dst/specbook-format.md +35 -8
  29. package/dst/specbook-format.yaml +12 -9
  30. package/dst/specbook-ignore.js +5 -5
  31. package/dst/specbook-link.js +1 -1
  32. package/dst/specbook-mcp.js +51 -34
  33. package/dst/specbook-parse-common.d.ts +4 -1
  34. package/dst/specbook-parse-common.js +15 -3
  35. package/dst/specbook-parse-semantic.d.ts +1 -1
  36. package/dst/specbook-parse-syntax.d.ts +1 -1
  37. package/dst/specbook-parse-syntax.js +28 -8
  38. package/dst/specbook-parse.js +6 -2
  39. package/dst/specbook-project.d.ts +7 -0
  40. package/dst/specbook-project.js +93 -0
  41. package/dst/specbook-theme.d.ts +1 -1
  42. package/dst/specbook-theme.js +9 -8
  43. package/dst/specbook-verbose.js +4 -9
  44. package/package.json +14 -6
package/README.md CHANGED
@@ -149,12 +149,14 @@ $ specbook export \
149
149
  [-b|--basedir <spec-md-file-basedir>] \
150
150
  [-o|--output [<format>:]<output-file>] \
151
151
  [-w|--watch] \
152
+ [-O|--omit <aspect>[,...]] \
152
153
  [...]
153
154
 
154
155
  $ specbook preview \
155
156
  [-v|--verbose [<level>]] \
156
157
  [-c|--config <schema-yaml-file>] \
157
158
  [-b|--basedir <spec-md-file-basedir>] \
159
+ [-O|--omit <aspect>[,...]] \
158
160
  [-a|--addr <ip-addr>] \
159
161
  [-p|--port <tcp-port>]
160
162
 
@@ -182,8 +184,9 @@ Options:
182
184
  of a reference coverage.
183
185
 
184
186
  - `-c|--config <schema-yaml-file>`:
185
- The YAML schema configuration (default: the bundled standard schema
186
- configuration `specbook-format.yaml`) determines the specification:
187
+ The YAML schema configuration (default: the `config` entry of the
188
+ project configuration file `.specbook.yaml`, else the bundled standard
189
+ schema configuration `specbook-format.yaml`) determines the specification:
187
190
  exactly the artifact files its `file` fields reference are loaded and
188
191
  parsed; all other Markdown files below the base directory are ignored.
189
192
  A referenced file which is absent is reported, unless all of its
@@ -210,12 +213,13 @@ Options:
210
213
  the schema of the configuration.
211
214
 
212
215
  - `-b|--basedir <spec-md-file-basedir>`:
213
- The base directory (default: `.`) is the directory the referenced
214
- artifact files are resolved against, and generated specification
215
- Markdown files are placed inside it, too.
216
+ The base directory (default: the `basedir` entry of the project
217
+ configuration file `.specbook.yaml`, else `.`) is the directory the
218
+ referenced artifact files are resolved against, and generated
219
+ specification Markdown files are placed inside it, too.
216
220
 
217
221
  - `-o|--output [<format>:]<output-file>` (`export` only):
218
- The output file (default: `-` for stdout) can be given multiple times.
222
+ The output file (`-` for stdout) is required and can be given multiple times.
219
223
  The format (`json`, `json5`, `yaml`, `toon`, `html`, `pdf`, or `md`)
220
224
  is inferred from the filename extension, unless it is explicitly given
221
225
  as a `<format>:` prefix, and plain `-` (stdout) defaults to JSON.
@@ -234,6 +238,23 @@ Options:
234
238
  file itself, as its own write would re-trigger the observation
235
239
  endlessly.
236
240
 
241
+ - `-O|--omit <aspect>[,...]` (`export` and `preview` only):
242
+ Omit content aspects (comma-separated, repeatable) from the HTML and
243
+ PDF outputs at generation time, instead of just leaving them foldable
244
+ in the browser. The aspects match the folding controls of the HTML
245
+ export: `diagram:graph`, `diagram:hub`, and `diagram:grid` omit all
246
+ diagrams of a type, `diagram:1` (alias: `diagram`), `diagram:2`, and
247
+ `diagram:3` omit all diagrams from an object tree nesting level on
248
+ (level 1 hence all, including the "Diagram of Contents" page), and
249
+ `text:long` cuts every table cell whose estimated number of text
250
+ lines towers over all other cells of its row (by the `maxCellHeight`
251
+ percentage of the object kind, default 40) at a word boundary, ending
252
+ it in a grey `[...]`. An omitted diagram leaves nothing behind and is not even
253
+ rendered, the folding controls of the omitted aspects leave the brand
254
+ bar, and the AST outputs (`json`, `json5`, `yaml`, `toon`) drop the
255
+ `diagram` fields of the omitted diagrams, too, while the `md` output
256
+ ignores the option.
257
+
237
258
  - `-a|--addr <ip-addr>`, `-p|--port <tcp-port>` (`preview` only):
238
259
  The IP address (default: `127.0.0.1`) and TCP port (default: `12345`)
239
260
  the live preview listens on. The HTML export is served on
@@ -262,8 +283,10 @@ Options:
262
283
  schema configuration (embedded into the Markdown or emitted as the
263
284
  raw file content), so the configuration costs fewer tokens: `0`
264
285
  emits it verbatim, `1` re-emits it with 2-space indentation, unwrapped
265
- lines, and without comments, `2` additionally leaves out its `refs` fields, and
266
- `3` additionally leaves out its `desc` fields of objects and properties.
286
+ lines, and without comments, `2` additionally leaves out its `refs`,
287
+ `diagram`, and `format` fields, and `3` additionally leaves out its
288
+ `desc` fields of objects and properties. The MCP tool `specbook_describe` defaults to
289
+ `2` instead, as its consumer is an LLM.
267
290
 
268
291
  - `-f|--format <format>` (`describe` only):
269
292
  The output format (default: `md`) switches from the rendered Markdown
@@ -278,13 +301,35 @@ Options:
278
301
  or embedded with `-e|--embed`, else the bundled standard one,
279
302
  embedded), or `spec` for the reference to the base directory.
280
303
 
304
+ The defaults of `-c|--config` and `-b|--basedir` are, unless
305
+ `SPECBOOK_CONFIG` and `SPECBOOK_BASEDIR` provide them (see below), taken
306
+ from the optional project configuration file `.specbook.yaml`, which is searched
307
+ in the current working directory and then upwards in all of its parent
308
+ directories (the closest one wins), so SpecBook works from any directory
309
+ below the project root:
310
+
311
+ ```yaml
312
+ config: docs/spec/schema.yaml
313
+ basedir: docs/spec
314
+ ```
315
+
316
+ The entry `config` is a single file or glob pattern or a list of them
317
+ (exactly like the repeatable `-c|--config`, including the literal
318
+ `std`), and the entry `basedir` is a directory. Relative paths resolve
319
+ against the directory of the `.specbook.yaml` itself, the file is
320
+ validated (an unknown entry or a wrongly typed value fails the command),
321
+ and both an explicitly supplied option and its environment variable
322
+ win over its entry.
323
+
281
324
  The default value of every CLI option `--xxx` can be overridden
282
325
  by a corresponding `SPECBOOK_XXX` environment variable (e.g.
283
326
  `SPECBOOK_BASEDIR`, `SPECBOOK_CONFIG`, `SPECBOOK_OUTPUT`,
284
327
  `SPECBOOK_VERBOSE`, `SPECBOOK_ADDR`, `SPECBOOK_PORT`), while an
285
328
  explicitly supplied option always wins. As `-c|--config` is repeatable,
286
329
  `SPECBOOK_CONFIG` carries a list of patterns separated by the path
287
- delimiter of the platform (`:` on Unix, `;` on Windows).
330
+ delimiter of the platform (`:` on Unix, `;` on Windows). Unlike all
331
+ others, `SPECBOOK_CONFIG` and `SPECBOOK_BASEDIR` are resolved by the API
332
+ itself, so they apply to the API methods and the MCP tools, too.
288
333
 
289
334
  Beyond those, the option-less `SPECBOOK_BROWSER` selects the browser
290
335
  printing the PDF export: a value carrying a path separator is taken as
@@ -298,6 +343,48 @@ Chrome* (the equivalent of `chrome`). An explicitly configured browser
298
343
  failing to launch fails the export instead of falling back onto another
299
344
  browser.
300
345
 
346
+ Agent Skill
347
+ -----------
348
+
349
+ For *Claude Code*, SpecBook ships the plugin `specbook`, which registers
350
+ the MCP service `specbook mcp` and provides the skill `/specbook` on top
351
+ of it:
352
+
353
+ ```bash
354
+ $ claude plugin marketplace add rse/specbook
355
+ $ claude plugin install specbook@specbook
356
+ ```
357
+
358
+ ```text
359
+ /specbook [-c|--config <yaml-file>] [-b|--basedir <basedir>] [<query>]
360
+ /specbook init [-c|--config <yaml-file>] [-b|--basedir <basedir>]
361
+ /specbook lint [-c|--config <yaml-file>] [-b|--basedir <basedir>] [-g|--gitignore]
362
+ /specbook export [-c|--config <yaml-file>] [-b|--basedir <basedir>] [-g|--gitignore]
363
+ [-O|--omit <aspect>[,...]] [-o|--output [<format>:]<output-file>] [...]
364
+ /specbook edit [-c|--config <yaml-file>] [-b|--basedir <basedir>] [-g|--grill]
365
+ [-r|--grill-rounds <n>] [-v|--verify] [-l|--loop] [<query>]
366
+ ```
367
+
368
+ Without a command, the skill *activates* the SpecBook know-how: it
369
+ learns the format and the schema configuration through the MCP tool
370
+ `specbook_describe`, so the specification can be queried and changed
371
+ ad-hoc in plain conversation for the remainder of the session, and it
372
+ directly serves an optional *query*. The agent invokes the skill this
373
+ way on its own, as soon as you want to read or change the specification.
374
+
375
+ The commands `init`, `lint`, and `export` just pass their options
376
+ through to the corresponding MCP tools. The command `edit` edits the
377
+ specification in one shot from a *query*: it learns the format and the
378
+ schema configuration through the MCP tool `specbook_describe`, reads the
379
+ related specification files, optionally interviews you about the open
380
+ points of the query first (`--grill`, for `--grill-rounds` rounds),
381
+ applies the change set, optionally lints the result and fixes the
382
+ reported diagnostics (`--verify`), and optionally asks for the next
383
+ query (`--loop`). All commands leave an absent `--config` and
384
+ `--basedir` to the MCP service, which resolves them through
385
+ `SPECBOOK_CONFIG` and `SPECBOOK_BASEDIR` and then through the
386
+ `.specbook.yaml` found from the working directory of the session upwards.
387
+
301
388
  Example: Simple
302
389
  ---------------
303
390
 
@@ -312,7 +399,7 @@ example data model:
312
399
  ### Generation
313
400
 
314
401
  ```bash
315
- $ specbook lint -v \
402
+ $ specbook export -v \
316
403
  -b smp/sample \
317
404
  -o smp/sample/sample.html \
318
405
  -o smp/sample/sample.pdf
@@ -337,7 +424,7 @@ application, based on **SpecBook**'s built-in "standard" schema:
337
424
  ### Generation
338
425
 
339
426
  ```bash
340
- $ specbook lint -v \
427
+ $ specbook export -v \
341
428
  -b smp/broadcast \
342
429
  -o smp/broadcast/broadcast.html \
343
430
  -o smp/broadcast/broadcast.pdf
@@ -1,13 +1,15 @@
1
+ import { projectFile } from "./specbook-project.js";
1
2
  import { renderDiagnostic, renderDiagnosticVerbose, type Diagnostic, type DiagnosticSeverity } from "./specbook-diagnostic.js";
2
3
  import { type LintResult } from "./specbook-cmd-lint.js";
3
- import { parseOutputSpec, formats, type ExportFormat } from "./specbook-cmd-export.js";
4
+ import { parseOutputSpec, formats, omitAspects, type ExportFormat } from "./specbook-cmd-export.js";
4
5
  import { previewAddr, previewPort } from "./specbook-cmd-preview.js";
5
6
  import { describeFormats, describeParts, parseDescribeFormat, parseDescribePart, compressLevels, parseCompressLevel, type DescribeFormat, type DescribePart, type CompressLevel } from "./specbook-cmd-describe.js";
6
7
  import { literal, renderVerbose, verbosities, verbosityOf, parseVerbosity, type Verbose, type VerboseLevel, type Verbosity } from "./specbook-verbose.js";
7
8
  export { literal, renderVerbose, verbosities, verbosityOf, parseVerbosity };
8
9
  export { type Verbose, type VerboseLevel, type Verbosity };
9
- export { formats, parseOutputSpec, type ExportFormat };
10
+ export { formats, parseOutputSpec, omitAspects, type ExportFormat };
10
11
  export { previewAddr, previewPort };
12
+ export { projectFile };
11
13
  export { describeFormats, describeParts, parseDescribeFormat, parseDescribePart };
12
14
  export { compressLevels, parseCompressLevel };
13
15
  export { type DescribeFormat, type DescribePart, type CompressLevel };
@@ -21,49 +23,48 @@ export type VerboseSink = (cmd: string, msg: string, level: VerboseLevel) => voi
21
23
  export interface SpecBookOptions {
22
24
  verbose?: VerboseSink;
23
25
  }
26
+ export interface ProjectOptions {
27
+ config?: string[];
28
+ basedir?: string;
29
+ cwd?: string;
30
+ }
24
31
  export declare class SpecBook {
25
32
  private verbose;
26
33
  constructor(options?: SpecBookOptions);
27
34
  private verboseOf;
35
+ private project;
28
36
  private configFiles;
29
37
  private requireConfig;
30
- init(options: {
31
- config?: string[];
32
- basedir?: string;
33
- }): Promise<string[]>;
34
- lint(options: {
35
- config?: string[];
36
- basedir?: string;
38
+ init(options: ProjectOptions): Promise<string[]>;
39
+ lint(options: ProjectOptions & {
37
40
  gitignore?: boolean;
38
41
  }): Promise<LintResult>;
39
42
  private renderFormats;
40
- export(options: {
41
- config?: string[];
42
- basedir?: string;
43
+ export(options: ProjectOptions & {
43
44
  formats?: ExportFormat[];
44
45
  realtime?: boolean;
46
+ omit?: string[];
45
47
  gitignore?: boolean;
48
+ rebase?: (string | undefined)[];
46
49
  }): Promise<Buffer[]>;
50
+ private rebaseOf;
47
51
  private observe;
48
- watch(options: {
49
- config?: string[];
50
- basedir?: string;
52
+ watch(options: ProjectOptions & {
51
53
  formats?: ExportFormat[];
52
54
  realtime?: boolean;
55
+ omit?: string[];
53
56
  gitignore?: boolean;
54
57
  outputs?: string[];
58
+ rebase?: (string | undefined)[];
55
59
  onExport: (buffers: Buffer[]) => void | Promise<void>;
56
60
  }): Promise<void>;
57
- preview(options: {
58
- config?: string[];
59
- basedir?: string;
61
+ preview(options: ProjectOptions & {
60
62
  addr?: string;
61
63
  port?: number;
64
+ omit?: string[];
62
65
  gitignore?: boolean;
63
66
  }): Promise<void>;
64
- describe(options: {
65
- config?: string[];
66
- basedir?: string;
67
+ describe(options: ProjectOptions & {
67
68
  embed?: boolean;
68
69
  compress?: CompressLevel;
69
70
  format?: DescribeFormat;
@@ -8,17 +8,19 @@ import * as path from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { glob } from "glob";
10
10
  import { loadConfig, configStatistics } from "./specbook-config.js";
11
+ import { loadProject, projectFile } from "./specbook-project.js";
11
12
  import { renderDiagnostic, renderDiagnosticVerbose } from "./specbook-diagnostic.js";
12
13
  import { initSpecification } from "./specbook-cmd-init.js";
13
14
  import { lint } from "./specbook-cmd-lint.js";
14
- import { exportSpecification, watchSpecification, parseOutputSpec, formats, requireBrowser } from "./specbook-cmd-export.js";
15
+ import { exportSpecification, watchSpecification, parseOutputSpec, formats, requireBrowser, parseOmit, omitAspects } from "./specbook-cmd-export.js";
15
16
  import { servePreview, previewAddr, previewPort } from "./specbook-cmd-preview.js";
16
17
  import { describeFormat, describeFormats, describeParts, parseDescribeFormat, parseDescribePart, compressLevels, parseCompressLevel } from "./specbook-cmd-describe.js";
17
18
  import { literal, renderVerbose, verbosities, verbosityOf, parseVerbosity } from "./specbook-verbose.js";
18
19
  /* re-export the central types for API consumers */
19
20
  export { literal, renderVerbose, verbosities, verbosityOf, parseVerbosity };
20
- export { formats, parseOutputSpec };
21
+ export { formats, parseOutputSpec, omitAspects };
21
22
  export { previewAddr, previewPort };
23
+ export { projectFile };
22
24
  export { describeFormats, describeParts, parseDescribeFormat, parseDescribePart };
23
25
  export { compressLevels, parseCompressLevel };
24
26
  export { renderDiagnostic, renderDiagnosticVerbose };
@@ -39,6 +41,35 @@ export class SpecBook {
39
41
  verboseOf(cmd) {
40
42
  return (msg, level = "notice") => this.verbose(cmd, msg, level);
41
43
  }
44
+ /* determine the effective project options: a given option wins over
45
+ its environment variable (SPECBOOK_CONFIG, a path-delimiter-separated
46
+ list of patterns, and SPECBOOK_BASEDIR), which in turn wins over
47
+ its entry in the project configuration file. A given working
48
+ directory anchors the given relative paths and keeps all paths
49
+ absolute, else the project ones are rendered relative to ours */
50
+ project(options, verbose) {
51
+ const cwd = path.resolve(options.cwd ?? ".");
52
+ const project = loadProject(cwd);
53
+ if (project !== undefined)
54
+ verbose(`using project configuration "${literal(project.file)}"`);
55
+ const env = (name) => {
56
+ const value = process.env[name];
57
+ return value !== undefined && value !== "" ? value : undefined;
58
+ };
59
+ const config = options.config !== undefined && options.config.length > 0 ? options.config :
60
+ env("SPECBOOK_CONFIG")?.split(path.delimiter).filter((pattern) => pattern !== "");
61
+ const basedir = options.basedir ?? env("SPECBOOK_BASEDIR");
62
+ const given = (value) => options.cwd !== undefined ? path.resolve(cwd, value) : value;
63
+ const derived = (value) => options.cwd !== undefined ? value : path.relative(cwd, value) || ".";
64
+ return {
65
+ config: config !== undefined && config.length > 0 ?
66
+ config.map((pattern) => pattern === "std" ? pattern : given(pattern)) :
67
+ project?.config?.map((pattern) => pattern === "std" ? pattern : derived(pattern)),
68
+ basedir: basedir !== undefined ? given(basedir) :
69
+ project?.basedir !== undefined ? derived(project.basedir) :
70
+ options.cwd !== undefined ? cwd : undefined
71
+ };
72
+ }
42
73
  /* determine the files of the YAML schema configuration out of the
43
74
  glob patterns, in the order of the patterns and alphabetically
44
75
  within a pattern, where the literal "std" names the bundled
@@ -78,23 +109,27 @@ export class SpecBook {
78
109
  below the base directory */
79
110
  async init(options) {
80
111
  const verbose = this.verboseOf("init");
81
- return initSpecification({ config: this.requireConfig(await this.configFiles(options.config), verbose),
82
- basedir: options.basedir ?? ".", verbose });
112
+ const project = this.project(options, verbose);
113
+ return initSpecification({ config: this.requireConfig(await this.configFiles(project.config), verbose),
114
+ basedir: project.basedir ?? ".", verbose });
83
115
  }
84
116
  /* lint the specification Markdown files below the base directory,
85
117
  where "gitignore" treats an artifact file Git excludes from its
86
118
  project exactly like an absent one */
87
119
  async lint(options) {
88
- return lint({ config: await this.configFiles(options.config),
89
- basedir: options.basedir ?? ".", gitignore: options.gitignore === true,
90
- verbose: this.verboseOf("lint") });
120
+ const verbose = this.verboseOf("lint");
121
+ const project = this.project(options, verbose);
122
+ return lint({ config: await this.configFiles(project.config),
123
+ basedir: project.basedir ?? ".", gitignore: options.gitignore === true, verbose });
91
124
  }
92
125
  /* render an already parsed specification into the requested formats
93
126
  (strict: any error diagnostic prevents the export, as a partial or
94
127
  invalid specification must never be emitted, while the warnings
95
- are just surfaced as notices), where "realtime" injects the
96
- client-side script of the live preview into the HTML */
97
- async renderFormats(result, requested, verbose, realtime) {
128
+ are just surfaced as notices), where "rendering" carries the
129
+ rendering options (the live preview script, the omitted aspects)
130
+ and "rebase" names, per requested format, the directory the image
131
+ references of the normalized Markdown are re-based onto */
132
+ async renderFormats(result, requested, verbose, rendering, rebase) {
98
133
  if (result.diagnostics.some((diagnostic) => diagnostic.severity === "error"))
99
134
  throw new Error("invalid specification:\n" +
100
135
  result.diagnostics.map(renderDiagnostic).join("\n"));
@@ -103,24 +138,38 @@ export class SpecBook {
103
138
  if (result.specification.artifacts.length === 0)
104
139
  throw new Error("unexportable specification: no artifacts found");
105
140
  const buffers = new Array();
106
- for (const format of requested)
107
- buffers.push(await exportSpecification(result.specification, format, verbose, result.config, realtime));
141
+ for (const [i, format] of requested.entries()) {
142
+ const dir = rebase?.[i];
143
+ buffers.push(await exportSpecification(result.specification, format, verbose, result.config, rendering, dir !== undefined ? { origins: result.origins, dir } : undefined));
144
+ }
108
145
  return buffers;
109
146
  }
110
147
  /* export the specification Markdown files below the base directory
111
148
  as JSON, JSON5, YAML, TOON, HTML, PDF, or normalized Markdown,
112
149
  parsing the input just once and returning one buffer per
113
- requested format */
150
+ requested format, where "rebase" names, per requested format, the
151
+ directory of the output file, onto which the image references of
152
+ the normalized Markdown are re-based (an absent one keeps them),
153
+ and "omit" names the (comma-separated) aspects the HTML, the PDF,
154
+ and (the diagrams) the AST leave out (see "omitAspects") */
114
155
  async export(options) {
115
156
  const verbose = this.verboseOf("export");
116
157
  const requested = options.formats ?? ["json"];
158
+ const omit = parseOmit(options.omit);
159
+ const project = this.project(options, verbose);
117
160
  /* a missing browser is an environment problem, so let the PDF
118
161
  export fail before the specification is even parsed */
119
162
  if (requested.includes("pdf"))
120
163
  await requireBrowser(verbose);
121
- const result = lint({ config: await this.configFiles(options.config),
122
- basedir: options.basedir ?? ".", gitignore: options.gitignore === true, verbose });
123
- return this.renderFormats(result, requested, verbose, options.realtime === true);
164
+ const result = lint({ config: await this.configFiles(project.config),
165
+ basedir: project.basedir ?? ".", gitignore: options.gitignore === true, verbose });
166
+ return this.renderFormats(result, requested, verbose, { realtime: options.realtime === true, omit }, this.rebaseOf(options));
167
+ }
168
+ /* anchor the given relative re-basing directories
169
+ at a given working directory */
170
+ rebaseOf(options) {
171
+ const cwd = options.cwd;
172
+ return cwd === undefined ? options.rebase : options.rebase?.map((dir) => dir !== undefined ? path.resolve(cwd, dir) : dir);
124
173
  }
125
174
  /* keep an export in sync with its sources (the shared core of
126
175
  "watch" and "preview"): every change of a configuration file, of an
@@ -132,24 +181,26 @@ export class SpecBook {
132
181
  once the initial export is done, while the active watcher keeps the
133
182
  process alive afterwards */
134
183
  async observe(options, verbose) {
135
- const config = await this.configFiles(options.config);
184
+ const omit = parseOmit(options.omit);
185
+ const project = this.project(options, verbose);
186
+ const config = await this.configFiles(project.config);
136
187
  return watchSpecification(async () => {
137
188
  /* the lint result carries the files to observe even for an
138
189
  invalid specification, and the configuration files are
139
190
  observed, too, so a failing export (even one due to an
140
191
  invalid configuration) still keeps the observe loop fed */
141
- const result = lint({ config, basedir: options.basedir ?? ".",
192
+ const result = lint({ config, basedir: project.basedir ?? ".",
142
193
  gitignore: options.gitignore === true, verbose });
143
194
  const files = [...config.map((file) => path.resolve(file)), ...result.files];
144
195
  /* an output which is itself an observed source would re-trigger
145
196
  the observation with its own write and hence feed an endless
146
197
  re-export loop, so refuse it before it is ever written */
147
- const collision = options.outputs?.find((output) => files.includes(path.resolve(output)));
198
+ const collision = options.outputs?.find((output) => files.includes(path.resolve(options.cwd ?? ".", output)));
148
199
  if (collision !== undefined)
149
200
  throw new Error(`the output "${collision}" is an observed source file, ` +
150
201
  "which would re-trigger the observation endlessly");
151
202
  try {
152
- await options.onExport(await this.renderFormats(result, options.formats, verbose, options.realtime));
203
+ await options.onExport(await this.renderFormats(result, options.formats, verbose, { realtime: options.realtime, omit }, this.rebaseOf(options)));
153
204
  }
154
205
  catch (err) {
155
206
  verbose("export failed: " +
@@ -161,7 +212,7 @@ export class SpecBook {
161
212
  /* export the specification like "export" and then keep the export
162
213
  in sync with its sources (see "observe"), where "outputs" names the
163
214
  files "onExport" writes, so an output which is itself an observed
164
- source can be refused */
215
+ source can be refused, and "rebase" and "omit" are the ones of "export" */
165
216
  async watch(options) {
166
217
  const verbose = this.verboseOf("export");
167
218
  const requested = options.formats ?? ["json"];
@@ -174,13 +225,14 @@ export class SpecBook {
174
225
  "http://<addr>:<port>/": the export is kept in sync with its
175
226
  sources (see "observe") and every fresh export is pushed to the
176
227
  connected browsers as an in-place document update, through the
177
- client-side script the "realtime" export injects into the HTML */
228
+ client-side script the "realtime" export injects into the HTML,
229
+ where "omit" is the one of "export" */
178
230
  async preview(options) {
179
231
  const verbose = this.verboseOf("preview");
180
232
  const server = await servePreview({ addr: options.addr ?? previewAddr,
181
233
  port: options.port ?? previewPort, verbose });
182
- return this.observe({ config: options.config, basedir: options.basedir,
183
- formats: ["html"], realtime: true, gitignore: options.gitignore,
234
+ return this.observe({ config: options.config, basedir: options.basedir, cwd: options.cwd,
235
+ formats: ["html"], realtime: true, omit: options.omit, gitignore: options.gitignore,
184
236
  onExport: ([html]) => server.update(html) }, verbose);
185
237
  }
186
238
  /* describe the generic SpecBook models and formats as Markdown (or
@@ -192,14 +244,15 @@ export class SpecBook {
192
244
  const verbose = this.verboseOf("describe");
193
245
  const format = options.format ?? "md";
194
246
  const part = options.part ?? "all";
247
+ const project = this.project(options, verbose);
195
248
  /* the schema-bearing parts require a schema configuration, so
196
249
  they fall back onto the bundled standard one, which is always
197
250
  embedded, as its bundled file is no meaningful reference */
198
- const given = options.config !== undefined && options.config.length > 0;
251
+ const given = project.config !== undefined;
199
252
  const standard = !given && (part === "all" || part === "schema");
200
- const config = given || standard ? await this.configFiles(options.config) : undefined;
253
+ const config = given || standard ? await this.configFiles(project.config) : undefined;
201
254
  const embed = options.embed === true || standard;
202
- const basedir = options.basedir ?? (part === "spec" ? "." : undefined);
255
+ const basedir = project.basedir ?? (part === "spec" ? "." : undefined);
203
256
  const schema = config !== undefined ? this.requireConfig(config, verbose) : undefined;
204
257
  verbose(`describing the "${literal(part)}" part of the SpecBook models and formats ` +
205
258
  `in the "${literal(format)}" format`);
@@ -8,7 +8,7 @@ import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
9
  import { Command, CommanderError } from "commander";
10
10
  import chalk from "chalk";
11
- import { SpecBook, renderDiagnostic, renderDiagnosticVerbose, renderVerbose, literal, parseOutputSpec, previewAddr, previewPort, describeFormats, describeParts, parseDescribeFormat, parseDescribePart, parseCompressLevel, parseVerbosity, verbosityOf, version } from "./specbook-api.js";
11
+ import { SpecBook, renderDiagnostic, renderDiagnosticVerbose, renderVerbose, literal, parseOutputSpec, previewAddr, previewPort, describeFormats, describeParts, parseDescribeFormat, parseDescribePart, parseCompressLevel, parseVerbosity, verbosityOf, projectFile, omitAspects, version } from "./specbook-api.js";
12
12
  import { serveMcp } from "./specbook-mcp.js";
13
13
  /* route verbose messages to stderr, keeping stdout reserved for the
14
14
  command outputs and the MCP protocol, and qualify every message with
@@ -69,17 +69,23 @@ const withVerboseOption = (command) => command
69
69
  "3: also the traces like the unreferenced objects; default: 0, bare flag: 1)", envDefault("verbose", "0"));
70
70
  /* provide the repeatable schema configuration option, whose files or
71
71
  glob patterns are merged in order (with "std" naming the bundled
72
- standard one), and determine its value, where the environment default
73
- carries a path-delimiter-separated list of patterns */
72
+ standard one), and determine its value, where an absent one is left
73
+ to the API, which resolves it through the environment variable
74
+ SPECBOOK_CONFIG and the project configuration file */
74
75
  const withConfigOption = (command, fallback) => command
75
76
  .option("-c, --config <yaml-file>", "YAML schema configuration file or glob pattern " +
76
77
  "(repeatable, merged in order, \"std\" for the bundled standard schema configuration; " +
77
- `default: ${fallback})`, (value, previous) => previous.concat(value), new Array());
78
- const configOf = (opts) => opts.config.length > 0 ? opts.config : envDefault("config")?.split(path.delimiter);
78
+ `default: SPECBOOK_CONFIG, else the "config" entry of "${projectFile}", else ${fallback})`, (value, previous) => previous.concat(value), new Array());
79
+ const configOf = (opts) => opts.config.length > 0 ? opts.config : undefined;
80
+ /* provide the base directory option, where an absent one is left to
81
+ the API, which resolves it through the environment variable
82
+ SPECBOOK_BASEDIR and the project configuration file */
83
+ const withBasedirOption = (command, fallback) => command
84
+ .option("-b, --basedir <directory>", "base directory of the specification Markdown files " +
85
+ `(default: SPECBOOK_BASEDIR, else the "basedir" entry of "${projectFile}", else ${fallback})`);
79
86
  /* provide the common options of the specification processing sub-commands,
80
87
  for which the YAML schema configuration falls back onto the standard one */
81
- const withCommonOptions = (command) => withConfigOption(withVerboseOption(command), "the bundled standard schema configuration")
82
- .option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."));
88
+ const withCommonOptions = (command) => withBasedirOption(withConfigOption(withVerboseOption(command), "the bundled standard schema configuration"), "\".\"");
83
89
  /* provide the Git exclude option of the specification *reading*
84
90
  sub-commands (not of "init", which creates artifact files instead of
85
91
  reading them), which treats an artifact file Git excludes from its
@@ -87,6 +93,16 @@ const withCommonOptions = (command) => withConfigOption(withVerboseOption(comman
87
93
  const withGitignoreOption = (command) => command
88
94
  .option("-g, --gitignore", "skip the artifact files excluded by the Git exclude rules " +
89
95
  "(the \".gitignore\" files, \"info/exclude\", and the global excludes file)", envDefaultFlag("gitignore", false));
96
+ /* provide the repeatable omit option of the HTML-rendering sub-commands,
97
+ whose aspects are left out of the output instead of being foldable,
98
+ and determine its value (validated by the API) */
99
+ const withOmitOption = (command) => command
100
+ .option("-O, --omit <aspect>[,...]", "omit content aspects from the HTML, the PDF, and (the " +
101
+ `diagrams) the AST outputs (repeatable): "diagram" (all) or "${omitAspects.join("\", \"")}"`, (value, previous) => previous.concat(value), new Array());
102
+ const omitOf = (opts) => {
103
+ const fallback = envDefault("omit");
104
+ return opts.omit.length > 0 ? opts.omit : (fallback !== undefined ? [fallback] : undefined);
105
+ };
90
106
  /* the help, version, and usage-error output Commander produces, which
91
107
  is collected instead of written directly, as Commander writes it
92
108
  synchronously and then terminates the process, truncating a piped
@@ -131,8 +147,9 @@ withGitignoreOption(withCommonOptions(program.command("lint")))
131
147
  const specbook = new SpecBook({ verbose: verboseOf(opts) });
132
148
  const result = await specbook.lint({ config: configOf(opts), basedir: opts.basedir,
133
149
  gitignore: opts.gitignore });
150
+ const detailed = parseVerbosity(opts.verbose) > 0;
134
151
  for (const diagnostic of result.diagnostics)
135
- await writeStdout(parseVerbosity(opts.verbose) > 0 ?
152
+ await writeStdout(detailed ?
136
153
  renderDiagnosticVerbose(diagnostic, process.stdout.isTTY === true) :
137
154
  `${renderDiagnostic(diagnostic)}\n`);
138
155
  if (result.diagnostics.some((diagnostic) => diagnostic.severity === "error"))
@@ -141,37 +158,48 @@ withGitignoreOption(withCommonOptions(program.command("lint")))
141
158
  verboseOf(opts)("lint", "specification valid", "notice");
142
159
  });
143
160
  /* the export command parses the input once and writes every output */
144
- withGitignoreOption(withCommonOptions(program.command("export")))
161
+ withOmitOption(withGitignoreOption(withCommonOptions(program.command("export"))))
145
162
  .description("export the specification Markdown files below the base directory " +
146
163
  "as JSON, JSON5, YAML, TOON, HTML, PDF, or normalized Markdown")
147
164
  .option("-w, --watch", "keep the outputs in sync by re-exporting on every source change", envDefaultFlag("watch", false))
148
165
  .option("-o, --output [<format>:]<output-file>", "output file (\"-\" for stdout, repeatable), with the format inferred " +
149
166
  "from the filename extension unless explicitly prefixed", (value, previous) => previous.concat(value), new Array())
150
167
  .action(async (opts) => {
151
- const specbook = new SpecBook({ verbose: verboseOf(opts) });
152
- const outputs = (opts.output.length > 0 ? opts.output : [envDefault("output") ?? "-"])
153
- .map(parseOutputSpec);
168
+ const verbose = verboseOf(opts);
169
+ const specbook = new SpecBook({ verbose });
170
+ const fallback = envDefault("output");
171
+ const given = opts.output.length > 0 ? opts.output : (fallback !== undefined ? [fallback] : []);
172
+ if (given.length === 0)
173
+ throw new Error("the export requires at least one output " +
174
+ "(use option \"-o [<format>:]<output-file>\", with \"-\" for stdout)");
175
+ const outputs = given.map(parseOutputSpec);
154
176
  /* a re-export has to land somewhere it can be picked up again,
155
177
  which a one-shot stdout stream cannot provide */
156
178
  if (opts.watch && outputs.some(({ output }) => output === "-"))
157
179
  throw new Error("the watch mode requires regular output files " +
158
180
  "(\"-\" for stdout is not supported)");
159
- /* parse the input once and export each distinct format once */
160
- const distinct = Array.from(new Set(outputs.map(({ format }) => format)));
181
+ /* parse the input once and export each distinct format once, the
182
+ normalized Markdown once per output directory, as its image
183
+ references are re-based onto it (but never for stdout) */
184
+ const rebaseOf = ({ format, output }) => format === "md" && output !== "-" ? path.dirname(path.resolve(output)) : undefined;
185
+ const keyOf = (spec) => `${spec.format}:${rebaseOf(spec) ?? ""}`;
186
+ const distinct = outputs.filter((spec, i) => outputs.findIndex((other) => keyOf(other) === keyOf(spec)) === i);
161
187
  const write = async (buffers) => {
162
- for (const { format, output } of outputs)
163
- await writeOutput(output, buffers[distinct.indexOf(format)], "export", verboseOf(opts));
188
+ for (const spec of outputs)
189
+ await writeOutput(spec.output, buffers[distinct.findIndex((other) => keyOf(other) === keyOf(spec))], "export", verbose);
164
190
  };
191
+ const formats = distinct.map(({ format }) => format);
192
+ const rebase = distinct.map(rebaseOf);
165
193
  if (opts.watch)
166
194
  await specbook.watch({ config: configOf(opts), basedir: opts.basedir,
167
- formats: distinct, gitignore: opts.gitignore,
195
+ formats, rebase, omit: omitOf(opts), gitignore: opts.gitignore,
168
196
  outputs: outputs.map(({ output }) => output), onExport: write });
169
197
  else
170
198
  await write(await specbook.export({ config: configOf(opts), basedir: opts.basedir,
171
- formats: distinct, gitignore: opts.gitignore }));
199
+ formats, rebase, omit: omitOf(opts), gitignore: opts.gitignore }));
172
200
  });
173
201
  /* the preview command serves the HTML export live in the browser */
174
- withGitignoreOption(withCommonOptions(program.command("preview")))
202
+ withOmitOption(withGitignoreOption(withCommonOptions(program.command("preview"))))
175
203
  .description("serve the HTML export of the specification Markdown files below the base " +
176
204
  "directory as a live preview, re-exported and reloaded on every source change")
177
205
  .option("-a, --addr <ip-addr>", "IP address to listen on", envDefault("addr", previewAddr))
@@ -182,17 +210,16 @@ withGitignoreOption(withCommonOptions(program.command("preview")))
182
210
  throw new Error(`invalid TCP port "${opts.port}"`);
183
211
  const specbook = new SpecBook({ verbose: verboseOf(opts) });
184
212
  await specbook.preview({ config: configOf(opts), basedir: opts.basedir, addr: opts.addr, port,
185
- gitignore: opts.gitignore });
213
+ omit: omitOf(opts), gitignore: opts.gitignore });
186
214
  });
187
215
  /* the describe command also describes the generic SpecBook models and
188
216
  formats alone, so its YAML schema configuration stays optional */
189
- withConfigOption(withVerboseOption(program.command("describe")), "the bundled standard schema configuration, embedded")
217
+ withBasedirOption(withConfigOption(withVerboseOption(program.command("describe")), "the bundled standard schema configuration, embedded"), "none")
190
218
  .description("describe the SpecBook models and formats as Markdown")
191
- .option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir"))
192
219
  .option("-e, --embed", "embed the given YAML schema configuration instead of just referencing it", envDefaultFlag("embed", false))
193
220
  .option("-z, --compress [level]", "compression level of the emitted YAML schema configuration " +
194
221
  "(0: verbatim, 1: re-emitted with 2-space indentation and without comments, " +
195
- "2: also without \"refs\" fields, " +
222
+ "2: also without \"refs\", \"diagram\", and \"format\" fields, " +
196
223
  "3: also without \"desc\" fields; default and bare flag: 1)", envDefault("compress", "1"))
197
224
  .option("-f, --format <format>", `output format (${describeFormats.join(", ")})`, envDefault("format", "md"))
198
225
  .option("-p, --part <part>", `document part (${describeParts.join(", ")})`, envDefault("part", "all"))